Skip to content

Add wallet descriptor management#171

Merged
kwsantiago merged 22 commits intomainfrom
Add-wallet-descriptor
Feb 25, 2026
Merged

Add wallet descriptor management#171
kwsantiago merged 22 commits intomainfrom
Add-wallet-descriptor

Conversation

@wksantiago
Copy link
Contributor

@wksantiago wksantiago commented Feb 24, 2026

Summary

  • Add wallet descriptor screen for proposing, approving, exporting, and deleting multisig wallet descriptors
  • Security hardening: atomic state mutations, account switch cleanup, input validation
  • UI fixes: FlowRow for network chip layout, index-based dividers, dialog race fix

Test Results

Tested on-device with 2x Pixel 9a + keep-desktop (2-of-3 FROST group, signet):

# Test Result
1 Unit tests PASS
2 Install APK PASS
3 Propose descriptor (signet) PASS
4 3-way coordination (propose → contribute → finalize → ack) PASS
5 Export (Sparrow format) PASS
6 Export (Raw format) PASS
7 Delete descriptor + confirm cleanup PASS
8 Account switching (no stale state) PASS

Device Configuration

Key Findings

  • Full 3-way descriptor coordination works end-to-end over Nostr relays
  • All 3 shares contribute and ACK correctly, session transitions to Complete
  • Descriptor stored and displayed with correct network/timestamp
  • Export copies descriptor to clipboard in both Sparrow and raw formats
  • Delete shows confirmation dialog, removes descriptor, count updates to 0
  • Account switch clears all session state and descriptors cleanly

Summary by CodeRabbit

  • New Features

    • Wallet Descriptors card in Apps showing descriptor count and entry to a Wallet Descriptors management screen.
    • Wallet Descriptors screen to propose, export, delete, and manage descriptor sessions with session-driven UI and dialogs.
  • Bug Fixes

    • Descriptor session state now clears when switching accounts to avoid stale state.
  • Tests

    • Comprehensive tests for descriptor session state transitions and lifecycle.

@coderabbitai
Copy link

coderabbitai bot commented Feb 24, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a Wallet Descriptor feature: UI card and WalletDescriptorScreen, a DescriptorSessionManager with session state and callbacks, PollResult descriptorCount wiring and account-switch session clearing, plus unit tests for the session manager.

Changes

Cohort / File(s) Summary
UI Card
app/src/main/kotlin/io/privkey/keep/ConnectionCards.kt
Adds WalletDescriptorCard(descriptorCount, onClick) composable showing descriptor header and count/manage action; clickable and styled like existing cards.
Main app wiring
app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt, app/src/main/kotlin/io/privkey/keep/MainActivity.kt
Register DescriptorSessionManager callbacks at startup; call DescriptorSessionManager.clearAll() on account switch; extend PollResult with descriptorCount; thread descriptorCount and onWalletDescriptorClick through MainScreen/AppsTab/HomeTab; show WalletDescriptorScreen when triggered.
Descriptor feature module
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt
New WalletDescriptorScreen composable, session lifecycle UI components, dialogs (propose/export/delete), and public DescriptorSessionState and DescriptorSessionManager (StateFlows, callbacks, pending-proposal management, clear functions).
Tests
app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt
Adds unit tests covering session state transitions, proposal lifecycle, per-network pending-proposal tracking, removal, reset semantics, approve→complete and reject flows, and callback registration behavior.

Sequence Diagrams

sequenceDiagram
    actor User
    participant UI as "WalletDescriptorScreen\nrgba(54,162,235,0.5)"
    participant Backend as "KeepMobile\nrgba(75,192,192,0.5)"
    participant Manager as "DescriptorSessionManager\nrgba(255,159,64,0.5)"

    User->>UI: Open Wallet Descriptor screen
    UI->>Backend: walletDescriptorList()
    Backend-->>UI: descriptors
    UI->>Manager: register callbacks / observe state
    Manager-->>UI: emit current state
Loading
sequenceDiagram
    actor User
    participant UI as "WalletDescriptorScreen\nrgba(54,162,235,0.5)"
    participant Backend as "KeepMobile\nrgba(75,192,192,0.5)"
    participant Manager as "DescriptorSessionManager\nrgba(255,159,64,0.5)"

    User->>UI: Propose new descriptor
    UI->>Backend: proposeDescriptor()
    Backend-->>Manager: onProposed(sessionId)
    Manager->>Manager: state = Proposed(sessionId)
    Manager-->>UI: state update (pending)
    User->>UI: Approve / contribute
    UI->>Backend: approveDescriptor()/contribute
    Backend-->>Manager: onContributionNeeded/onContributed
    Manager-->>UI: state updates (ContributionNeeded/Contributed)
    Backend-->>Manager: onComplete(sessionId, external, internal)
    Manager->>Manager: state = Complete(...)
    Manager-->>UI: emit completion (enable export/delete)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

Poem

🐰 I hopped in to count each descriptor bright,

Proposals twinkled under soft screen light,
Sessions hummed as callbacks took their cue,
Approve, export, complete — the flow anew,
A little rabbit cheers this multisig night.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title "Add wallet descriptor management" accurately and concisely summarizes the main feature addition in the changeset, which introduces wallet descriptor management UI, session lifecycle, and related functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch Add-wallet-descriptor

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (1)

52-100: Avoid duplicate pending proposals if callbacks repeat.

Defensive dedupe by sessionId prevents duplicate rows and repeated actions when the backend replays events.

♻️ Proposed dedupe safeguard
         override fun onContributionNeeded(proposal: DescriptorProposal) {
-            _pendingProposals.update { it + proposal }
+            _pendingProposals.update { existing ->
+                if (existing.any { it.sessionId == proposal.sessionId }) existing else existing + proposal
+            }
             _state.value = DescriptorSessionState.ContributionNeeded(proposal)
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 52 - 100, The pending proposals list can receive duplicates when
callbacks repeat; update the onContributionNeeded handler in
DescriptorSessionManager (inside createCallbacks) to dedupe by sessionId before
appending: instead of _pendingProposals.update { it + proposal } only add the
proposal when no existing item has the same DescriptorProposal.sessionId (e.g.,
_pendingProposals.update { list -> if (list.any { it.sessionId ==
proposal.sessionId }) list else list + proposal }); keep removePendingProposal,
clearAll and other state transitions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 102-266: The refreshDescriptors function currently calls
keepMobile.walletDescriptorList() without error handling, which can crash the
UI; update refreshDescriptors to wrap the network call in runCatching (or
try/catch) inside the existing scope.launch/withContext(Dispatchers.IO) block,
only assign to the descriptors state on success, and handle failures by showing
a Toast (or logging) instead of throwing; reference the refreshDescriptors
function, the descriptors mutable state, and keepMobile.walletDescriptorList to
locate where to add runCatching/onFailure handling.

---

Nitpick comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 52-100: The pending proposals list can receive duplicates when
callbacks repeat; update the onContributionNeeded handler in
DescriptorSessionManager (inside createCallbacks) to dedupe by sessionId before
appending: instead of _pendingProposals.update { it + proposal } only add the
proposal when no existing item has the same DescriptorProposal.sessionId (e.g.,
_pendingProposals.update { list -> if (list.any { it.sessionId ==
proposal.sessionId }) list else list + proposal }); keep removePendingProposal,
clearAll and other state transitions unchanged.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f26120f and 712ac4e.

📒 Files selected for processing (4)
  • app/src/main/kotlin/io/privkey/keep/ConnectionCards.kt
  • app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt
  • app/src/main/kotlin/io/privkey/keep/MainActivity.kt
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/kotlin/io/privkey/keep/MainActivity.kt (1)

403-428: ⚠️ Potential issue | 🟠 Major

walletDescriptorList() failure in the polling loop would halt all polling.

Line 413 adds keepMobile.walletDescriptorList().size to the polling block. If this call throws (e.g., network error), the entire repeat loop terminates because there's no try-catch around the polling body. This is a pre-existing fragility for all calls in this block, but descriptor list fetching (potentially a network call) increases the surface area.

Consider wrapping the descriptor count fetch independently so a failure doesn't halt peer/pending-count polling:

Proposed safeguard
                     val pc = if (h) keepMobile.getPendingRequests().size else 0
-                    val dc = if (h) keepMobile.walletDescriptorList().size else 0
+                    val dc = if (h) runCatching { keepMobile.walletDescriptorList().size }.getOrDefault(0) else 0
                     PollResult(h, s, a, k, p, pc, dc)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt` around lines 403 - 428,
The polling loop inside LaunchedEffect constructs a PollResult by calling
keepMobile.walletDescriptorList().size which, if it throws, will cancel the
entire repeat loop; change the descriptor count fetch to be isolated and
resilient—call walletDescriptorList() inside its own try/catch or runCatching
when building the PollResult (or compute dc separately before constructing
PollResult), and on failure return a safe fallback (e.g., previous
descriptorCount or 0) and log the error instead of letting the exception
propagate; ensure the rest of the PollResult values (hasShare, getPeers,
getPendingRequests, etc.) remain untouched so polling continues even if
walletDescriptorList() fails.
♻️ Duplicate comments (1)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (1)

120-130: Previous review finding addressed — refreshDescriptors is now guarded with runCatching.

The error handling wraps the IO call and shows a toast on failure. Looks good.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 120 - 130, The previous issue has been addressed:
refreshDescriptors now wraps the IO call in runCatching and shows a toast on
failure; no further changes required—leave the refreshDescriptors function
(scope.launch { runCatching { withContext(Dispatchers.IO) {
keepMobile.walletDescriptorList() } }... }) and its descriptors assignment/toast
handling as-is.
🧹 Nitpick comments (4)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (3)

55-103: DescriptorSessionManager as a global singleton limits testability and multi-account scenarios.

A singleton object couples the state machine to global scope, making it harder to test in isolation (as noted in the test file) and impossible to run concurrent sessions for different accounts. Consider refactoring to a class instance managed by DI if multi-account or parallel descriptor flows become a requirement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 55 - 103, DescriptorSessionManager is defined as a global object
which prevents isolated testing and multi-account/parallel sessions; refactor it
into a regular class (e.g., class DescriptorSessionManager) so state flows
(_state, _pendingProposals) and methods (createCallbacks, clearSessionState,
clearAll, removePendingProposal) become instance members, then register/provide
that class via your DI container or construct per-test/per-account; update all
call sites and tests to obtain an instance (instead of referencing the object)
and ensure createCallbacks returns callbacks bound to the instance so session
events modify the instance state rather than global state.

483-501: Redundant re-parsing on button click.

Lines 484–486 already parse and validate threshold/timelockMonths to compute valid, which disables the button. Lines 489–491 re-parse and re-validate on click. This is defensive, but the double work could be simplified by passing the pre-validated values directly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 483 - 501, The confirm button currently parses threshold and
timelockMonths twice (once to compute valid and again inside the onClick), so
parse them once into local values (e.g., parsedThreshold and parsedTimelock)
outside the TextButton and use those for both the enabled check and the onClick;
update the confirmButton lambda (the TextButton in WalletDescriptorScreen) to
use the pre-parsed parsedThreshold/parsedTimelock and call onPropose(network,
listOf(RecoveryTierConfig(parsedThreshold, parsedTimelock))) directly, removing
the redundant re-parsing and duplicate range checks while keeping the same
validity constraints (1u..15u and 1u..120u).

62-89: Non-atomic dual-state mutation in callbacks.

In onContributionNeeded, onComplete, and onFailed, two separate StateFlows (_pendingProposals and _state) are updated sequentially. Between these two writes, an observer could see an inconsistent snapshot (e.g., proposals updated but state still stale). In practice this is low-risk since Compose batches recompositions, but worth noting if these callbacks can fire off the main thread.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 62 - 89, The callbacks update two separate StateFlows
(_pendingProposals and _state) non-atomically in onContributionNeeded,
onComplete and onFailed; protect these paired writes by serializing them with a
small mutex (e.g., declare a private val descriptorCallbacksMutex = Mutex()) and
wrap the paired updates inside descriptorCallbacksMutex.withLock { ... } so
onContributionNeeded does descriptorCallbacksMutex.withLock {
_pendingProposals.update { it + proposal }; _state.value =
DescriptorSessionState.ContributionNeeded(proposal) } and similarly wrap
removePendingProposal + _state.value assignments in onComplete and onFailed (and
update the removePendingProposal implementation to use the same mutex if it
mutates _pendingProposals) to ensure observers never see a partial state.
app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt (1)

12-17: Singleton-based test isolation relies on sequential execution.

Since DescriptorSessionManager is a global object, these tests are only safe when run sequentially. JUnit 4 does this by default within a single class, but if any other test class (or future parallel test runner) also interacts with DescriptorSessionManager, you could see flaky failures. Consider noting this constraint or, longer-term, refactoring DescriptorSessionManager to be injectable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt`
around lines 12 - 17, Tests rely on the global DescriptorSessionManager
singleton which can cause cross-test or cross-class flakiness under parallel
execution; update the test to explicitly document this constraint and make the
expectation clear (add a comment above setup() referencing
DescriptorSessionManager.clearAll()), and as a longer-term fix refactor the
singleton into an injectable instance (replace the DescriptorSessionManager
object with a class/instance or introduce a provider interface and use
dependency injection in the code under test) so tests can create and inject
isolated instances instead of relying on the global clearAll().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 225-241: The dialog is being closed regardless of success because
showProposeDialog = false is set outside the runCatching block; update the
ProposeDescriptorDialog onPropose handler so that you only set showProposeDialog
= false when keepMobile.walletDescriptorPropose succeeds (e.g., move the
assignment into the runCatching success path or use onSuccess), and optionally
preserve the input state on failure so the user doesn't need to re-enter data;
look for the onPropose lambda and the runCatching surrounding
keepMobile.walletDescriptorPropose to make this change.
- Around line 264-282: The delete dialog is always dismissed because
showDeleteConfirm = null is executed regardless of success; change the flow so
showDeleteConfirm is only cleared when the deletion actually succeeds (e.g.,
move the assignment into the success path of runCatching or call it inside
.onSuccess after keepMobile.walletDescriptorDelete and refreshDescriptors
complete), and leave the DeleteDescriptorDialog open on .onFailure while still
showing the Toast; update the handlers around DeleteDescriptorDialog,
runCatching, keepMobile.walletDescriptorDelete and refreshDescriptors
accordingly so failures do not close the dialog prematurely.

---

Outside diff comments:
In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt`:
- Around line 403-428: The polling loop inside LaunchedEffect constructs a
PollResult by calling keepMobile.walletDescriptorList().size which, if it
throws, will cancel the entire repeat loop; change the descriptor count fetch to
be isolated and resilient—call walletDescriptorList() inside its own try/catch
or runCatching when building the PollResult (or compute dc separately before
constructing PollResult), and on failure return a safe fallback (e.g., previous
descriptorCount or 0) and log the error instead of letting the exception
propagate; ensure the rest of the PollResult values (hasShare, getPeers,
getPendingRequests, etc.) remain untouched so polling continues even if
walletDescriptorList() fails.

---

Duplicate comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 120-130: The previous issue has been addressed: refreshDescriptors
now wraps the IO call in runCatching and shows a toast on failure; no further
changes required—leave the refreshDescriptors function (scope.launch {
runCatching { withContext(Dispatchers.IO) { keepMobile.walletDescriptorList() }
}... }) and its descriptors assignment/toast handling as-is.

---

Nitpick comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 55-103: DescriptorSessionManager is defined as a global object
which prevents isolated testing and multi-account/parallel sessions; refactor it
into a regular class (e.g., class DescriptorSessionManager) so state flows
(_state, _pendingProposals) and methods (createCallbacks, clearSessionState,
clearAll, removePendingProposal) become instance members, then register/provide
that class via your DI container or construct per-test/per-account; update all
call sites and tests to obtain an instance (instead of referencing the object)
and ensure createCallbacks returns callbacks bound to the instance so session
events modify the instance state rather than global state.
- Around line 483-501: The confirm button currently parses threshold and
timelockMonths twice (once to compute valid and again inside the onClick), so
parse them once into local values (e.g., parsedThreshold and parsedTimelock)
outside the TextButton and use those for both the enabled check and the onClick;
update the confirmButton lambda (the TextButton in WalletDescriptorScreen) to
use the pre-parsed parsedThreshold/parsedTimelock and call onPropose(network,
listOf(RecoveryTierConfig(parsedThreshold, parsedTimelock))) directly, removing
the redundant re-parsing and duplicate range checks while keeping the same
validity constraints (1u..15u and 1u..120u).
- Around line 62-89: The callbacks update two separate StateFlows
(_pendingProposals and _state) non-atomically in onContributionNeeded,
onComplete and onFailed; protect these paired writes by serializing them with a
small mutex (e.g., declare a private val descriptorCallbacksMutex = Mutex()) and
wrap the paired updates inside descriptorCallbacksMutex.withLock { ... } so
onContributionNeeded does descriptorCallbacksMutex.withLock {
_pendingProposals.update { it + proposal }; _state.value =
DescriptorSessionState.ContributionNeeded(proposal) } and similarly wrap
removePendingProposal + _state.value assignments in onComplete and onFailed (and
update the removePendingProposal implementation to use the same mutex if it
mutates _pendingProposals) to ensure observers never see a partial state.

In
`@app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt`:
- Around line 12-17: Tests rely on the global DescriptorSessionManager singleton
which can cause cross-test or cross-class flakiness under parallel execution;
update the test to explicitly document this constraint and make the expectation
clear (add a comment above setup() referencing
DescriptorSessionManager.clearAll()), and as a longer-term fix refactor the
singleton into an injectable instance (replace the DescriptorSessionManager
object with a class/instance or introduce a provider interface and use
dependency injection in the code under test) so tests can create and inject
isolated instances instead of relying on the global clearAll().

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e600d7b and 7683c47.

📒 Files selected for processing (3)
  • app/src/main/kotlin/io/privkey/keep/MainActivity.kt
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt
  • app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (1)

67-69: Deduplicate pending proposals by sessionId before appending.

Line 68 always appends, so callback retries/replays can create duplicate pending rows for the same session. Guarding by sessionId keeps UI/actions idempotent.

Proposed refinement
 override fun onContributionNeeded(proposal: DescriptorProposal) {
-    _pendingProposals.update { it + proposal }
+    _pendingProposals.update { current ->
+        if (current.any { it.sessionId == proposal.sessionId }) current else current + proposal
+    }
     _state.value = DescriptorSessionState.ContributionNeeded(proposal)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 67 - 69, onContributionNeeded currently always appends the incoming
DescriptorProposal to _pendingProposals, causing duplicates if the callback is
retried; update the override fun onContributionNeeded(proposal:
DescriptorProposal) to check existing _pendingProposals for a matching sessionId
on DescriptorProposal and only append if no entry with the same sessionId
exists, then continue to set _state.value =
DescriptorSessionState.ContributionNeeded(proposal); reference the
_pendingProposals.update call and the DescriptorProposal.sessionId property when
implementing the guard to keep the action idempotent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 122-128: The onFailure handlers attached to your runCatching
blocks (e.g., the block wrapping withContext(Dispatchers.IO) {
keepMobile.walletDescriptorList() } that sets descriptors and shows Toasts)
currently swallow all throwables including CancellationException; update each
onFailure { ... } to rethrow if the caught exception is a CancellationException
(e.g., if (it is CancellationException) throw it) and only show the error Toast
or handle non-cancellation exceptions otherwise so coroutine cancellation
semantics are preserved.

---

Nitpick comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 67-69: onContributionNeeded currently always appends the incoming
DescriptorProposal to _pendingProposals, causing duplicates if the callback is
retried; update the override fun onContributionNeeded(proposal:
DescriptorProposal) to check existing _pendingProposals for a matching sessionId
on DescriptorProposal and only append if no entry with the same sessionId
exists, then continue to set _state.value =
DescriptorSessionState.ContributionNeeded(proposal); reference the
_pendingProposals.update call and the DescriptorProposal.sessionId property when
implementing the guard to keep the action idempotent.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7683c47 and 03df530.

📒 Files selected for processing (1)
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt

@wksantiago wksantiago linked an issue Feb 24, 2026 that may be closed by this pull request
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (1)

262-275: ⚠️ Potential issue | 🟡 Minor

Keep the export dialog open when export fails.

showExportDialog = null at Line 274 runs even after failure, so a transient export error forces users to reopen and reselect format.

Suggested fix
             onExport = { format ->
                 scope.launch {
                     runCatching {
-                        val exported = withContext(Dispatchers.IO) {
+                        withContext(Dispatchers.IO) {
                             keepMobile.walletDescriptorExport(descriptor.groupPubkey, format)
                         }
-                        copySensitiveText(context, exported)
-                        Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
+                    }.onSuccess { exported ->
+                        copySensitiveText(context, exported)
+                        Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
+                        showExportDialog = null
                     }.onFailure { e ->
                         if (e is CancellationException) throw e
                         Log.w(TAG, "Failed to export descriptor", e)
                         Toast.makeText(context, "Export failed", Toast.LENGTH_LONG).show()
                     }
-                    showExportDialog = null
                 }
             },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 262 - 275, The export dialog is being closed unconditionally
because showExportDialog = null runs after runCatching even on failure; change
the flow so showExportDialog is cleared only on successful export—e.g., use
runCatching(...).onSuccess { showExportDialog = null } (or move the assignment
into the success block after copySensitiveText) while keeping the current
CancellationException rethrow behavior and existing error handling for
keepMobile.walletDescriptorExport and copySensitiveText.
🧹 Nitpick comments (1)
app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt (1)

101-110: Add a regression test for duplicate sessionId proposals.

DescriptorSessionManager deduplicates pending proposals by sessionId in app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (Lines 73-75), but this suite doesn’t currently lock that behavior.

Suggested test case
+    `@Test`
+    fun `duplicate sessionId does not duplicate pending proposals`() = runTest {
+        val callbacks = DescriptorSessionManager.createCallbacks()
+        val p1 = makeProposal(sessionId = "dup", network = "bitcoin")
+        val p2 = makeProposal(sessionId = "dup", network = "signet")
+
+        callbacks.onContributionNeeded(p1)
+        callbacks.onContributionNeeded(p2)
+
+        val pending = DescriptorSessionManager.pendingProposals.first()
+        assertEquals(1, pending.size)
+        assertEquals("dup", pending[0].sessionId)
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt`
around lines 101 - 110, The test currently checks accumulation of proposals but
doesn’t assert the deduplication-by-sessionId behavior; add a new or update the
existing test in DescriptorSessionManagerTest to submit two proposals with the
same sessionId (using DescriptorSessionManager.createCallbacks() and
callbacks.onContributionNeeded(...)) and one with a different sessionId, then
read DescriptorSessionManager.pendingProposals.first() and assert that proposals
are deduplicated by sessionId (i.e., size reflects unique sessionIds and the
duplicate sessionId appears only once); reference the methods createCallbacks,
onContributionNeeded and the pendingProposals flow to locate and modify the
test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt`:
- Around line 399-404: The current runCatching around
keepMobile.walletDescriptorSetCallbacks swallows all non-cancellation
exceptions; update the onFailure handler so that CancellationException is
rethrown but any other Throwable is surfaced (e.g., logged and/or reported)
instead of ignored. Specifically, modify the runCatching { ... }.onFailure block
that surrounds
keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks())
so non-cancellation errors are logged with a clear message (include the
exception) and/or reported to your error-tracking system, ensuring failures to
register descriptor callbacks are visible while preserving cancellation
behavior; keep the surrounding lifecycleOwner.lifecycle.repeatOnLifecycle usage
intact.

---

Duplicate comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 262-275: The export dialog is being closed unconditionally because
showExportDialog = null runs after runCatching even on failure; change the flow
so showExportDialog is cleared only on successful export—e.g., use
runCatching(...).onSuccess { showExportDialog = null } (or move the assignment
into the success block after copySensitiveText) while keeping the current
CancellationException rethrow behavior and existing error handling for
keepMobile.walletDescriptorExport and copySensitiveText.

---

Nitpick comments:
In
`@app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt`:
- Around line 101-110: The test currently checks accumulation of proposals but
doesn’t assert the deduplication-by-sessionId behavior; add a new or update the
existing test in DescriptorSessionManagerTest to submit two proposals with the
same sessionId (using DescriptorSessionManager.createCallbacks() and
callbacks.onContributionNeeded(...)) and one with a different sessionId, then
read DescriptorSessionManager.pendingProposals.first() and assert that proposals
are deduplicated by sessionId (i.e., size reflects unique sessionIds and the
duplicate sessionId appears only once); reference the methods createCallbacks,
onContributionNeeded and the pendingProposals flow to locate and modify the
test.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 03df530 and 3baf1e0.

📒 Files selected for processing (3)
  • app/src/main/kotlin/io/privkey/keep/MainActivity.kt
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt
  • app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
app/src/main/kotlin/io/privkey/keep/MainActivity.kt (2)

399-407: ⚠️ Potential issue | 🟡 Minor

Callback registration failure still lacks user-visible notification.

Log.e was added (addressing logging from the prior review), but the user-facing Toast was not. If callback registration fails silently, descriptor session updates stop working without any indication to the user.

🛡️ Proposed fix to surface the failure
     }.onFailure {
         if (it is CancellationException) throw it
         Log.e("MainActivity", "Failed to set descriptor callbacks", it)
+        Toast.makeText(appContext, "Descriptor updates unavailable", Toast.LENGTH_SHORT).show()
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt` around lines 399 - 407,
The runCatching block that calls
keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks())
logs failures but doesn't notify the user; update the onFailure handler to
rethrow CancellationException as it does, and for other exceptions show a
user-visible Toast (or Snackbar) with a short error message (e.g., "Failed to
set descriptor callbacks") in addition to calling Log.e, so the user is informed
when descriptor callback registration fails; ensure you use the Activity context
(this or lifecycleOwner) when creating the Toast and keep the existing Log.e
invocation.

416-420: ⚠️ Potential issue | 🟡 Minor

Silent swallow of walletDescriptorList() failures in the polling hot path.

Non-CancellationException failures are silently collapsed to 0 on every 10-second poll cycle with no log output, making it impossible to distinguish "no descriptors" from "call failed".

🐛 Proposed fix to log failures
     val dc = if (h) {
         runCatching { keepMobile.walletDescriptorList().size }
-            .onFailure { if (it is CancellationException) throw it }
+            .onFailure { e ->
+                if (e is CancellationException) throw e
+                Log.w("MainActivity", "Failed to fetch descriptor count", e)
+            }
             .getOrDefault(0)
     } else 0
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt` around lines 416 - 420,
The polling hot path currently swallows all non-CancellationException errors
from keepMobile.walletDescriptorList() by using runCatching().getOrDefault(0);
change that runCatching block to rethrow CancellationException but log other
exceptions before returning 0 so failures aren’t indistinguishable from "no
descriptors". Specifically, update the block around
keepMobile.walletDescriptorList() (the dc assignment) to: use runCatching {
keepMobile.walletDescriptorList().size } .onFailure { if (it is
CancellationException) throw it else Log.e("MainActivity", "walletDescriptorList
failed", it) } .getOrDefault(0) (or use the existing app logger if one exists)
so errors are recorded while preserving the 0 fallback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt`:
- Around line 399-407: The runCatching block that calls
keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks())
logs failures but doesn't notify the user; update the onFailure handler to
rethrow CancellationException as it does, and for other exceptions show a
user-visible Toast (or Snackbar) with a short error message (e.g., "Failed to
set descriptor callbacks") in addition to calling Log.e, so the user is informed
when descriptor callback registration fails; ensure you use the Activity context
(this or lifecycleOwner) when creating the Toast and keep the existing Log.e
invocation.
- Around line 416-420: The polling hot path currently swallows all
non-CancellationException errors from keepMobile.walletDescriptorList() by using
runCatching().getOrDefault(0); change that runCatching block to rethrow
CancellationException but log other exceptions before returning 0 so failures
aren’t indistinguishable from "no descriptors". Specifically, update the block
around keepMobile.walletDescriptorList() (the dc assignment) to: use runCatching
{ keepMobile.walletDescriptorList().size } .onFailure { if (it is
CancellationException) throw it else Log.e("MainActivity", "walletDescriptorList
failed", it) } .getOrDefault(0) (or use the existing app logger if one exists)
so errors are recorded while preserving the 0 fallback.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3baf1e0 and 2c42d1a.

📒 Files selected for processing (1)
  • app/src/main/kotlin/io/privkey/keep/MainActivity.kt

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (1)

291-304: ⚠️ Potential issue | 🟡 Minor

Keep the export dialog open when export fails.

The dialog is closed regardless of outcome, so transient failures force the user to re-open and repeat actions.

Suggested fix
                 scope.launch {
                     runCatching {
                         val exported = withContext(Dispatchers.IO) {
                             keepMobile.walletDescriptorExport(descriptor.groupPubkey, format)
                         }
                         copySensitiveText(context, exported)
                         Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
+                    }.onSuccess {
+                        showExportDialog = null
                     }.onFailure { e ->
                         if (e is CancellationException) throw e
                         Log.w(TAG, "Failed to export descriptor", e)
                         Toast.makeText(context, "Export failed", Toast.LENGTH_LONG).show()
                     }
                     isExporting = false
-                    showExportDialog = null
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 291 - 304, The export dialog is always closed because isExporting
is set to false and showExportDialog is cleared after the runCatching block;
change this so that showExportDialog is only cleared when the export succeeds.
Specifically, move the showExportDialog = null (and any UI success state
transitions) into the success path of the runCatching (e.g., after
copySensitiveText/Toast on success) and ensure isExporting is still reset to
false in both success and failure paths; keep the existing CancellationException
rethrow and leave showExportDialog untouched inside the onFailure block so the
dialog remains open on error.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 209-219: The removePendingProposal call and inFlightSessions
update are executed regardless of whether the backend action succeeded; change
the flow so DescriptorSessionManager.removePendingProposal(proposal.sessionId)
and inFlightSessions = inFlightSessions - proposal.sessionId are only executed
after a successful call to block(proposal.sessionId). In the scope.launch block
around runCatching { withContext(Dispatchers.IO) { block(proposal.sessionId) }
}, move the removal/update into the success path (e.g., in .onSuccess or after
checking result.isSuccess) while keeping the existing .onFailure behavior that
rethrows CancellationException and logs/shows the toast on errors.

In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt`:
- Around line 419-424: The current logic forces dc to 0 on any transient failure
when fetching keepMobile.walletDescriptorList(), which can show a spurious empty
state; change it to preserve the previous descriptor count until a successful
fetch by making the fetch update conditional: run the runCatching block and only
replace the existing dc value when it succeeds (rethrow CancellationException),
otherwise leave the prior dc unchanged; apply the same pattern for the other
occurrences (the similar blocks around lines 432 and 1213) and ensure the value
passed into PollResult(...) is the preserved previous count unless the fetch
succeeds.

---

Duplicate comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 291-304: The export dialog is always closed because isExporting is
set to false and showExportDialog is cleared after the runCatching block; change
this so that showExportDialog is only cleared when the export succeeds.
Specifically, move the showExportDialog = null (and any UI success state
transitions) into the success path of the runCatching (e.g., after
copySensitiveText/Toast on success) and ensure isExporting is still reset to
false in both success and failure paths; keep the existing CancellationException
rethrow and leave showExportDialog untouched inside the onFailure block so the
dialog remains open on error.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3baf1e0 and 935ff0a.

📒 Files selected for processing (3)
  • app/src/main/kotlin/io/privkey/keep/MainActivity.kt
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt
  • app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/src/main/kotlin/io/privkey/keep/MainActivity.kt (1)

399-409: Add retry/backoff for descriptor callback registration.

Line 399 currently attempts callback registration once. If startup fails transiently, real-time descriptor updates stay disabled until app restart.

Suggested resiliency patch
 LaunchedEffect(Unit) {
-    runCatching {
-        withContext(Dispatchers.IO) {
-            keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks())
-        }
-    }.onSuccess {
-        DescriptorSessionManager.setCallbacksRegistered(true)
-    }.onFailure {
-        if (it is CancellationException) throw it
-        Log.e("MainActivity", "Failed to set descriptor callbacks", it)
-        DescriptorSessionManager.setCallbacksRegistered(false)
-    }
+    var registered = false
+    repeat(3) { attempt ->
+        if (registered) return@repeat
+        registered = runCatching {
+            withContext(Dispatchers.IO) {
+                keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks())
+            }
+            true
+        }.onFailure {
+            if (it is CancellationException) throw it
+            Log.e("MainActivity", "Failed to set descriptor callbacks (attempt ${attempt + 1})", it)
+        }.getOrDefault(false)
+        if (!registered && attempt < 2) delay(1_000L * (attempt + 1))
+    }
+    DescriptorSessionManager.setCallbacksRegistered(registered)
     lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt` around lines 399 - 409,
The current one-shot registration in runCatching around
keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks())
can fail transiently; change this to a retry loop with exponential backoff (and
a small max attempt/count and cancellation handling) so registration is retried
on non-CancellationException failures; on each attempt call
keepMobile.walletDescriptorSetCallbacks from a Dispatchers.IO context, break and
call DescriptorSessionManager.setCallbacksRegistered(true) on success, and after
exhausting retries log the final error and call
DescriptorSessionManager.setCallbacksRegistered(false); ensure
CancellationException is rethrown immediately and backoff uses delay between
attempts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt`:
- Around line 399-409: The current one-shot registration in runCatching around
keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks())
can fail transiently; change this to a retry loop with exponential backoff (and
a small max attempt/count and cancellation handling) so registration is retried
on non-CancellationException failures; on each attempt call
keepMobile.walletDescriptorSetCallbacks from a Dispatchers.IO context, break and
call DescriptorSessionManager.setCallbacksRegistered(true) on success, and after
exhausting retries log the final error and call
DescriptorSessionManager.setCallbacksRegistered(false); ensure
CancellationException is rethrown immediately and backoff uses delay between
attempts.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 935ff0a and 798dd03.

📒 Files selected for processing (2)
  • app/src/main/kotlin/io/privkey/keep/MainActivity.kt
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 536-538: In WalletDescriptorScreen update the invalid
supportingText lambda syntax: change the double-brace usages like "{{
Text(\"Required\") }}" and "{{ Text(\"Must be between 1 and 15\") }}" to
single-brace composable lambdas "{ Text(\"Required\") }" and "{ Text(\"Must be
between 1 and 15\") }" so the supportingText parameter (a (`@Composable` () ->
Unit)?) receives the correct lambda type; search for these patterns near the
threshold/thresholdError usages and replace accordingly.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 798dd03 and 94b1fdf.

📒 Files selected for processing (1)
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt

@wksantiago
Copy link
Contributor Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 25, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@wksantiago
Copy link
Contributor Author

@coderabbitai review

@wksantiago wksantiago self-assigned this Feb 25, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (1)

549-551: ⚠️ Potential issue | 🔴 Critical

Fix invalid supportingText lambda shape ({{ ... }}) to match Compose API.

These branches pass an extra lambda layer and can fail type-checking. Use single-brace composable lambdas.

Suggested fix
                     supportingText = when {
-                        threshold.isEmpty() -> {{ Text("Required") }}
-                        thresholdError -> {{ Text("Must be between 1 and 15") }}
+                        threshold.isEmpty() -> { Text("Required") }
+                        thresholdError -> { Text("Must be between 1 and 15") }
                         else -> null
                     },
@@
                     supportingText = when {
-                        timelockMonths.isEmpty() -> {{ Text("Required") }}
-                        timelockError -> {{ Text("Must be between 1 and 120") }}
+                        timelockMonths.isEmpty() -> { Text("Required") }
+                        timelockError -> { Text("Must be between 1 and 120") }
                         else -> null
                     },
#!/bin/bash
# Verify no invalid double-brace supportingText lambdas remain in this file.
rg -nP '\{\{\s*Text\(' app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt
# Expected: no matches

Also applies to: 565-566

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 549 - 551, The supportingText branches in WalletDescriptorScreen
use an extra lambda layer (e.g., supportingText = when { threshold.isEmpty() ->
{{ Text("Required") }} ... }), which doesn't match Compose's expected
single-brace composable lambda; update each occurrence (the branches referencing
threshold.isEmpty() and thresholdError and the similar occurrences later) to use
a single-brace lambda form (e.g., { Text("...") }) so supportingText is a proper
`@Composable` lambda returning Text.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 180-185: DisposableEffect currently calls
DescriptorSessionManager.clearAll() onDispose which leaves
DescriptorSessionManager.active false and the composable never re-enables
callbacks on re-entry; fix by reactivating the descriptor session when the
screen is entered (inside the DisposableEffect init) — call whatever API on
DescriptorSessionManager that re-enables callbacks (e.g.,
DescriptorSessionManager.activate() or set active = true) when DisposableEffect
runs, and keep DescriptorSessionManager.clearAll() on onDispose so the session
is cleaned up on exit.

---

Duplicate comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 549-551: The supportingText branches in WalletDescriptorScreen use
an extra lambda layer (e.g., supportingText = when { threshold.isEmpty() -> {{
Text("Required") }} ... }), which doesn't match Compose's expected single-brace
composable lambda; update each occurrence (the branches referencing
threshold.isEmpty() and thresholdError and the similar occurrences later) to use
a single-brace lambda form (e.g., { Text("...") }) so supportingText is a proper
`@Composable` lambda returning Text.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 94b1fdf and d249af4.

📒 Files selected for processing (3)
  • app/src/main/kotlin/io/privkey/keep/MainActivity.kt
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt
  • app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (1)

359-387: Dead Idle branch in when — remove the early guard or consolidate.

Line 361 returns for Idle before the when, making is DescriptorSessionState.Idle -> return at line 369 unreachable. The early guard was likely added to also satisfy when exhaustiveness, but it creates confusing dead code.

♻️ Proposed cleanup
 `@Composable`
 private fun SessionStatusCard(state: DescriptorSessionState) {
-    if (state is DescriptorSessionState.Idle) return
-
     val (statusText, statusColor) = when (state) {
         is DescriptorSessionState.Proposed -> "Proposed — waiting for contributions" to MaterialTheme.colorScheme.primary
         is DescriptorSessionState.ContributionNeeded -> "Contribution needed" to MaterialTheme.colorScheme.tertiary
         is DescriptorSessionState.Contributed -> "Share ${state.shareIndex} contributed" to MaterialTheme.colorScheme.secondary
         is DescriptorSessionState.Complete -> "Descriptor complete" to MaterialTheme.colorScheme.primary
         is DescriptorSessionState.Failed -> "Failed: ${truncateText(state.error, 80)}" to MaterialTheme.colorScheme.error
-        is DescriptorSessionState.Idle -> return
+        is DescriptorSessionState.Idle -> return  // single guard, no pre-check needed
     }
     // ...

Or simply remove the top-level guard and keep all logic in the when (single early exit point).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 359 - 387, Remove the redundant early guard in SessionStatusCard:
delete the top-level "if (state is DescriptorSessionState.Idle) return" and rely
on the existing when branch that includes "is DescriptorSessionState.Idle ->
return" so the when remains exhaustive and there is a single, non-dead early
exit point; update nothing else in SessionStatusCard or the
DescriptorSessionState branches.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 285-298: The isProposing/isExporting/isDeleting flags are set
after the runCatching block so if onFailure rethrows a CancellationException the
flag reset is skipped; wrap the long-running call inside a try/finally in each
coroutine launched from scope.launch (e.g. the block that calls
keepMobile.walletDescriptorPropose in WalletDescriptorScreen) and move the
`isProposing = false` (and analogous `isExporting = false` / `isDeleting =
false`) into the finally so it always runs; preserve the existing onFailure
logic (rethrow CancellationException) inside the try block or onFailure handler
but ensure the finally resets the flag regardless of exceptions.

---

Nitpick comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 359-387: Remove the redundant early guard in SessionStatusCard:
delete the top-level "if (state is DescriptorSessionState.Idle) return" and rely
on the existing when branch that includes "is DescriptorSessionState.Idle ->
return" so the when remains exhaustive and there is a single, non-dead early
exit point; update nothing else in SessionStatusCard or the
DescriptorSessionState branches.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d249af4 and f25d88f.

📒 Files selected for processing (1)
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt

@kwsantiago kwsantiago merged commit 4869e06 into main Feb 25, 2026
3 checks passed
@kwsantiago kwsantiago deleted the Add-wallet-descriptor branch February 25, 2026 20:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add wallet descriptor coordination UI

2 participants